第7章 文档结构和库
本章内容:前面的学习基本完成了制作简单游戏所有必备的知识,案例也几乎就是完整的小游戏了。但是随着我们代码量的增加,我们发现使用单一的文档结构看起来比较混乱,不再能够满足我们的项目了,这章我们讲解如何把一个项目合理的进行文件布局。同时,我们会看一些库文件的代码,来让他们更加规范的加入到我们的项目。
游戏文档结构以及库使用规范
文档结构,看起来很高大上的样子,实际上就是你怎么安排你的文件更加合理,合理的理解为你想找的东西,你一下子就能找到。而每个人的思维习惯不同,这也导致文档结构的千差万别,下面介绍的,仅仅是笔者对文档结构的一个习惯性安排,读者可以根据自身需求安排自己想要的模式。
文档架构
- conf.lua
从读取顺序来讲,这个文件是最先读取的,它的作用是在引擎初始化时,对其进行设置。各个设置请自行wiki。1io.stdout:setvbuf("no")
这个代码一般我加入到conf里,它控制着print这种标准输出是否缓存。如果缓存的话,只有当缓存溢出或结束时才会输出。这种是为了避免过多的文件读写。但我们print主要用于debug,所以要关闭缓存。
main.lua
个人喜好上,把所有需要引用的库以及全局变量放到这里。避免在此文件以外定义全局变量。过多的全局变量不但会增加代码出错的因素,而且会降低效率。场景库以及场景
一般而言,游戏是分场景的,比如入场的splash(中文咋说,就是商标,团队图标等淡入淡出),游戏开始菜单,游戏场景,结束画面等。下面解释一下场景库的原理。123456789101112131415161718192021222324252627282930313233state = {}state[1] = {load = function() print("load1")end,enter = function() print("enter1") end,update = function() print("update1") end,draw = function() print("draw1") end,leave = function() print("leave1") end,}state[2] = {load = function() print("load2")end,enter = function() print("enter2") end,update = function() print("update2") end,draw = function() print("draw2") end,leave = function() print("leave1") end,}for i,v in ipairs(state) dostate[i]:load()endlocal cStateIndex =1function setState(index)state[cStateIndex]:leave()local current = state[index]current:enter()love.update = current.updatelove.draw = current.drawcStateIndex = indexendsetState[2]
解读一下上面的代码,首先定义了两个state,他们相当于两个场景,他们都有各自的load,update,draw方法,对应着love的回调,但是又多了一个enter方法和一个leave方法。他们意思是进入和退出场景时的回调。
然后对每一个场景进行load,当然,你也可以动态的load,只不过尚未读取的场景是不能被使用的。
接下来是设置场景的方法setState,从函数内部我们可以看出,它实际上是动态的赋予love.update和love.draw于我们场景对应的方法,从而实现场景跳转。当然,还有从上一个场景离开和新场景进入的方法。
上面的代码仅仅是为了解释场景的工作原理,除了update,draw之外,所有love回调都需要被重新定向。比较完整的场景库之一hump.gamestate是笔者比较喜欢的场景库,下面有一段我惯用的导入场景的方法。
上面的代码的含义是我们从scene文件夹中读取所有文件,除去.lua四个字符为名字,定义场景。然后跳转场景到splash场景。这里有个filesystem的方法。这里只知道含义即可。
- 文件夹
上面说到了scene文件夹,我们就系统说一下我的一般项目文件夹的排布,当然并不是统一的,根据自己的喜好来。
首先,我们知道main.lua和conf.lua只有放在根目录下才能发挥作用。这个不变。
然后是library目录,里面存放第三方或者自己写的库文件。至于库文件的规范待会说。
然后是scene目录,存放各种场景文件。
接下来asset目录,存放各种资源,包括image,sound,mucis,font,tile,spine等子目录。
然后是object目录,存放游戏对象的类模板。
最后是misc目录,各种各样的单个脚本,或者不适合放在其他目录的文件。
当然,上面是以类型为分类的,也可以以游戏对象为分类,把一种游戏对象的资源放在一个文件夹下。如game目录,player目录,enemy目录等,将类文件,素材文件,读取脚本等都放在一起。
文件和库的导入。
lua的require机制需要比较熟悉,require的参数为一个lua文件,且不含.lua文件拓展名。其实现的实际上是下面代码:
你需要理解的是:
首先,任何单独的lua文件都是被看做一个function的,如果你在文件中不注册全局变量也没有返回值,那他不会对其他文件产生污染。也就是要么你在文件中写个全局变量或者注册在全局变量下,比如love.yourlib,要么你需要有一个返回值return,不然你的文件等于0。
然后,通过require引入的文件具有唯一性,就是无论你引入多少次require该文件,它返回的都是同一个引用(ref/指针),所以,你无法使用这种方法来作为模板复制。但是有下面几种情况:
这种是返回一个function,所以在使用时,还需要再call一下。
如果希望每次都返回不同的ref的话,需要使用另一种方法。
刚刚讲了,一个文件的本质是一个function,所以上面的方法的返回值也是function需要再call一下才能发挥作用。
另外一个很神奇的函数setfenv(func,env),它可以对一个函数指定外部环境,也就是说,把这个func放在env块下。但是,注意的是,如果使用这个,env是个很纯净的空间,是没有任何love和lua自带环境的,所以,你需要手动导入或者设置metatable.
我们会发现,文件中的abc并没有污染到全局变量,而是成为了test的变量。这种形式比较适合在abc.lua中写脚本,因为书写会比较方便。而且不会污染外部。
库
库是love的一个使用核心,因为Love本身不提供高级功能,而库是已经用原始语言编写好的一些具有一定功能的代码块。通过引入他们可以方便的形成一些游戏功能。它也是代码复用的一种形式。另外,虽然love的luajit支持动态链接库,但由于我们希望一套代码对应所有平台,因此,尽量使用lua为语言的库。当然,lua功能无法支持到的函数或者某些比较需要效率的函数,就只能外部引入c库了。
下面介绍一种库的形式
单例库
这种库一般在游戏中只需要一个实例,或者仅仅是一个表,表下有若干的函数。这种库,直接用一个本地变量传入就可以了。比如
这种库有时候也会自动的在全局里面注册一个变量来承载他们。但是这种做法并不推荐,因为我们一般不看库内部的文件,而内部注册的东西很有可能被外部某个同名变量覆盖,所以,不要这么做。
即时库
这种库是最爽的了,所谓即时(IMM)式,是它在内部,几乎不(或者完全不)存储来自外部的数据,而是在每帧把数据手动的传入到库中,使其发挥作用,类似于面向组件编程那样。 这种库要求每帧都要将数据传入库中,可能对于用惯oop的人不太适应,但是它的优点在于,它太干净了。我完全不用担心什么东西没有被释放。只要不向里面传东西,这个对象就不存在。比较有名的包括:UI库suit和多边形碰撞库hardoncollider,他们是同一个人写的(hump也是他写的),文档十分详细。
对象库
这是最常见的一种库,他本身是一个类,通过call或者new方法来产生实例。而实例具有我们有用的方法,原理就是lua的metatable大法。这种库太多了,比如我们之前用到的bump,vector,tween等。
补丁库
这也是比较常见的一种做法。通过库里的代码,扩充现有的功能。比如math库,string库,love库等等。
上面是个简单的例子。这种库,直接require即可。另外还有一些快捷方式的补丁:
猴子补丁
首先可以百度一下这种补丁是如何从大猩猩到猴子的^^,首先要说的是,这种补丁很灵活,也很危险。它是动态的替换一些原来系统已经存在的功能,使其具有另一项功能。因为lua中的所有值(表和函数)都是承接在__G(全局)中,修改一个内建函数跟改其他表的键值是一样的。这里举个简单的例子:
以后调用print的你就成了猴子-。-;另一个例子:
上面的函数就对circle进行了补丁,使其支持”outline”作为参数。
自定义库
用户可以自己写库,或者根据某些现有的库来加入自己的功能。或者仅仅是自己常用的代码段。在自己写库时,要遵循下面几个原则。
- 不必要时,不要污染全局变量。
- 尽量不要产生额外的数据,而是利用传入的数据。比如在库中经常出现(function() return {} end)这种形式的代码。
- 如果使用多文件形式的库,需要建立库名文件夹,库的入口在init.lua中,另外还要注意,多文件相互引用时,需要对文件位置做判断,常见的方法 local BASE = (…) .. ‘.’,base为当前目录。
- 尽量少做猴子补丁,除非你清除这意味着什么。因为它会极大降低程序的兼容性,以及给debug添乱。
编程时间
本单元不再进行新的内容了,而是完成之前的飞机游戏为一个完整的游戏。
设计阶段
本游戏定位为一个生存类射击游戏。玩家使用鼠标控制飞机移动,鼠标按下时发射子弹。敌人会随机的从地图外围进入场地,并以当前玩家位置,按固定角度飞行,期间自动发射子弹,敌人离开外围时重新生成一个。子弹在离开外围时销毁。子弹玩家或敌人扣减生命值,敌人死亡则销毁,玩家加分。玩家生命值为0时游戏结束。
- 使用库
碰撞库:bump, 类库middleclass, 延迟库delay, 动画库anim8, 场景库hump.gamestate, 个人代码片段util - 建立场景
intro场景,显示游戏名称和一个背景图片,按任意键后进入游戏场景;
game场景,以上述游戏规则,玩家生命值在玩家飞机上方显示绿条。敌人不显示生命。当玩家生命为0时,延迟2两秒进入到gameover场景。
gamover场景:用红色显示背景图片,按esc结束游戏,按回车则重新开始。 - 导入资源
本项目只有一个spritesheet资源。 - 建立游戏对象
玩家飞机类,使用anim8建立动画对象,建立bump碰撞盒,飞机转向玩家鼠标的方向。按住鼠标左键开枪。绘制时除了动画,还需要一个血条显示。
敌人飞机类,大多数跟玩家一样,初始位置需要随机在画面外围,并指向画面中心,飞行过程不改变方向,发射低速子弹。飞机在飞离画面后,随机外围重新生成。
子弹类,子弹初始位置为飞机的发射位置,子弹需要留下发射的ref(引用),以便判断子弹是否来自于友方。子弹沿固定路线,固定速度飞行。子弹飞离外围后销毁。
爆炸类,仅仅是一个爆炸动画,在life结束后,删除这个对象。 - 碰撞设计
子弹之间无碰撞,飞机之间无碰撞。只有子弹与敌方的飞机(玩家子弹-敌人飞机,敌人子弹-玩家飞机)能够发生碰撞。碰撞时,子弹销毁。被击中飞机生命值降低hp,当生命值为0时飞机爆炸,并产生爆炸画面。
实现阶段
这里只讲一些难点代码。
游戏沙盒的建立,之所以建立游戏沙盒,是因为我们希望我们的游戏对象们有一个家,而且不到处乱跑。这样我们可以很方便的找到他们的同时,也可以清理沙盒来重新游戏。因为直接重新给game赋值就可以重置了。这里面我们把敌我的飞机都放在planes里,是因为他们的行为模式类似,不需要额外建立表来存储。bullets出于未来可能进行单独遍历的原因,单独拿出来,不然也可以放在planes里面。others存储那些没有碰撞的对象。timer和count分别代表游戏时间和击毁敌机数量。
下面的实例化代码很简单,这里就不讲了。
bump的初始化及碰撞盒绑定
上面的代码介绍了如何初始化世界,绑定碰撞盒和解除绑定。讲解几个要点。
初始化世界的参数的含义是格子的大小。一般而言,与我们基本的游戏单位大小相当就可以了。至于这个大小有什么用,我们之前碰撞中讲过,是一种格子优化。另外,不同的世界,拥有不同的碰撞,他们是不相关的,在重置游戏时,直接建立新世界,就不用对过去生成的碰撞盒进行一一删除了。
因为游戏对象和碰撞盒分别处于两个世界,因此删除一个世界的对象并不意味着对应的对象能够自动解除。因此,在删除游戏对象时,要手动的删除碰撞盒。不然它会永远停留在那里产生碰撞,而且无法释放游戏对象的资源,因为碰撞盒有对游戏对象的引用。
延迟命令
上面的代码完成了关于玩家收到伤害及场景切换的逻辑。其中用到了一个delay库。这个库很小,是笔者自己写的,原理也很简单。就是一个定制化的计时器,含义为在2秒之后,执行参数2中的函数。这个库要求在全局的update中添加delay:update(dt)才能够运行。
延迟库除了延迟功能本身,最大的一个特点是可以利用代码所在块的uppervalue,也就是上查值。关于上查值的意义在于。你可以使用定义这个函数内部的局域变量,而不用担心调用时的环境。关于Lua上查值的相关概念,请自行百度。
碰撞遍历
这里截取的是bullet的碰撞。之前提到过,bump特点之一就是只有在物体移动时,主动手动遍历,才有相关的动作,这样提高了运行效率。
bump的碰撞分为3个部分,filter,move,collision。
filter类似于分组,它是一个函数参数,通过判断碰撞体游戏对象来返回碰撞类型。比如马里奥和墙返回滑动,马里奥和硬币返回穿过,马里奥和弹簧返回反弹。
move是将碰撞盒随着游戏对象的位置变化移动到指定位置,同时根据filter的碰撞类型,通过遍历所有碰撞盒,来返回碰撞相关的结果。他会产生3个返回值,前两个是位置,后一个是碰撞结果。注意:反弹类型的碰撞并不会帮你把游戏对象的速度改变。而是根据碰撞盒交叠的情况来计算碰撞后的位置。
collision是碰撞的结果,是一个表,通过遍历这个表你会得到所有跟他有碰撞的碰撞盒。通过判断他们对应的游戏对象,我们来决定自己的游戏对象产生何种行为,比如速度取反,加速度停止,受到伤害,或者得到金币等。
其他部分实际我们上一章已经学习过了。这里不再多说。
作业:
- 增加背景音乐,开炮声音,击中声音,爆炸声音等。
- 新增一些敌人。具有不同的行为表现。比如速度慢飞机子弹比较多,速度快的飞机本身也具有碰撞(自毁型)。
- 增加一些道具。道具实际上跟子弹在行为表现上差不多。移动比较慢,或者随机移动。碰撞后的行为比如增加生命,全屏杀伤,等等。
- 增加其他种类枪。这个函数主要加在飞机开火的位置。原来生成1个,现在生成2个,3个等等。然后手动的去修改他们的方向和位置。
- 增加枪的行为表现,比如跟踪的(算法同跟踪鼠标),先慢后快的,绕圈的等等。
本章代码
由于本章主要体现代码结构,附件已打包。请自行下载。